<

写真フィルターカルーセルを作成する

フィルターを使用すると写真がより良く見えることは誰もが知っています。 このレシピでは、スクロール可能なオブジェクトを構築します。 フィルター選択カルーセル。

次のアニメーションはアプリの動作を示しています。

Photo Filter Carousel

このレシピは写真とフィルターから始まります すでに設置されています。フィルターはcolorcolorBlendModeのプロパティImageウィジェット。

セレクターリングとダークグラデーションを追加

選択したフィルター円が枠内に表示されます。 セレクターリング。さらに、暗いグラデーションは、 利用可能なフィルターの背後にあるため、コントラストが向上します フィルターと選択した写真の間。

という名前の新しいステートフル ウィジェットを作成します。FilterSelector使い慣れたもの セレクターを実装します。

@immutable
class FilterSelector extends StatefulWidget {
  const FilterSelector({
    super.key,
  });

  @override
  State<FilterSelector> createState() => _FilterSelectorState();
}

class _FilterSelectorState extends State<FilterSelector> {
  @override
  Widget build(BuildContext context) {
    return const SizedBox();
  }
}

を追加します。FilterSelectorウィジェットを既存の ウィジェットツリー。位置を決めますFilterSelectorウィジェット 写真の上、下、中央にあります。

Stack(
  children: [
    Positioned.fill(
      child: _buildPhotoWithFilter(),
    ),
    const Positioned(
      left: 0.0,
      right: 0.0,
      bottom: 0.0,
      child: FilterSelector(),
    ),
  ],
),

以内FilterSelectorウィジェット、 セレクターリングを上に表示する を使用した暗いグラデーションStackウィジェット。

class _FilterSelectorState extends State<FilterSelector> {
  static const _filtersPerScreen = 5;
  static const _viewportFractionPerItem = 1.0 / _filtersPerScreen;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final itemSize = constraints.maxWidth * _viewportFractionPerItem;

        return Stack(
          alignment: Alignment.bottomCenter,
          children: [
            _buildShadowGradient(itemSize),
            _buildSelectionRing(itemSize),
          ],
        );
      },
    );
  }

  Widget _buildShadowGradient(double itemSize) {
    return SizedBox(
      height: itemSize * 2 + widget.padding.vertical,
      child: const DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              Colors.transparent,
              Colors.black,
            ],
          ),
        ),
        child: SizedBox.expand(),
      ),
    );
  }

  Widget _buildSelectionRing(double itemSize) {
    return IgnorePointer(
      child: Padding(
        padding: widget.padding,
        child: SizedBox(
          width: itemSize,
          height: itemSize,
          child: const DecoratedBox(
            decoration: BoxDecoration(
              shape: BoxShape.circle,
              border: Border.fromBorderSide(
                BorderSide(width: 6, color: Colors.white),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

セレクターサークルのサイズと背景のグラデーション カルーセル内の個々のフィルターのサイズに依存します 呼ばれたitemSize。のitemSize利用可能な幅によって異なります。 したがって、fLayoutBuilderウィジェットは、 利用可能なスペースを調べてから、 個々のフィルターitemSize

セレクターリングには、IgnorePointerウィジェット カルーセルのインタラクティブ性が追加されると、 セレクターリングが干渉しないはずです イベントをタップしてドラッグします。

カルーセル内の各フィルター項目には、 画像に色が適用された円形の画像 これは、関連するフィルターの色に対応します。

という新しいステートレス ウィジェットを定義します。FilterItem単一のリスト項目を表示します。

@immutable
class FilterItem extends StatelessWidget {
  const FilterItem({
    super.key,
    required this.color,
    this.onFilterSelected,
  });

  final Color color;
  final VoidCallback? onFilterSelected;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onFilterSelected,
      child: AspectRatio(
        aspectRatio: 1.0,
        child: Padding(
          padding: const EdgeInsets.all(8),
          child: ClipOval(
            child: Image.network(
              'https://docs.flutter.dev/cookbook/img-files'
              '/effects/instagram-buttons/millenial-texture.jpg',
              color: color.withOpacity(0.5),
              colorBlendMode: BlendMode.hardLight,
            ),
          ),
        ),
      ),
    );
  }
}

ユーザーがドラッグすると、フィルター項目が左右にスクロールします。スクロールには必要なものがあります ある種のScrollableウィジェット。

水平方向の使用を検討することもできますListViewウィジェット、 しかし、ListViewウィジェットは最初の要素を次の位置に配置します。 使用可能なスペースの先頭ではなく、 セレクターリングが位置する中央。

PageViewウィジェットはカルーセルに適しています。 あPageViewウィジェットはその子を 利用可能なスペースの中心に配置し、スナップ物理を提供します。 スナップ物理はアイテムを中心にスナップさせるものです。 ユーザーがどこでドラッグを放しても。

ウィジェット ツリーを構成して、PageView

@override
Widget build(BuildContext context) {
  return LayoutBuilder(builder: (context, constraints) {
    final itemSize = constraints.maxWidth * _viewportFractionPerItem;

    return Stack(
      alignment: Alignment.bottomCenter,
      children: [
        _buildShadowGradient(itemSize),
        _buildCarousel(itemSize),
        _buildSelectionRing(itemSize),
      ],
    );
  });
}

Widget _buildCarousel(double itemSize) {
  return Container(
    height: itemSize,
    margin: widget.padding,
    child: PageView.builder(
      itemCount: widget.filters.length,
      itemBuilder: (context, index) {
        return const SizedBox();
      },
    ),
  );
}

それぞれを構築するFilterItem内のウィジェットPageView指定されたウィジェットに基づくindex

Color itemColor(int index) => widget.filters[index % widget.filters.length];

Widget _buildCarousel(double itemSize) {
  return Container(
    height: itemSize,
    margin: widget.padding,
    child: PageView.builder(
      itemCount: widget.filters.length,
      itemBuilder: (context, index) {
        return Center(
          child: FilterItem(
            color: itemColor(index),
            onFilterSelected: () {},
          ),
        );
      },
    ),
  );
}

PageViewウィジェットにはすべてが表示されますFilterItemウィジェットを左右にドラッグできます。 ただ、今のところはそれぞれFilterItemウィジェットはかかります 画面の幅全体に、 そしてそれぞれFilterItemウィジェットが表示される 同じサイズと不透明度で。あるはずです 五FilterItem画面上のウィジェット、 そしてそのFilterItemウィジェットは縮小する必要があり、 画面の中心から遠ざかるにつれてフェードします。

これら両方の問題の解決策は、 あるPageViewController。のPageViewControllerviewportFractionプロパティは表示に使用されます 多数FilterItem画面上のウィジェット 同じ時間です。それぞれを再構築するFilterItemウィジェット としてPageViewController変更により、次のことが可能になります それぞれ変更しますFilterItemウィジェットのサイズと不透明度 ユーザーがスクロールすると。

を作成しますPageViewControllerに接続しますPageViewウィジェット。

class _FilterSelectorState extends State<FilterSelector> {
  static const _filtersPerScreen = 5;
  static const _viewportFractionPerItem = 1.0 / _filtersPerScreen;

  late final PageController _controller;

  Color itemColor(int index) => widget.filters[index % widget.filters.length];

  @override
  void initState() {
    super.initState();
    _controller = PageController(
      viewportFraction: _viewportFractionPerItem,
    );
    _controller.addListener(_onPageChanged);
  }

  void _onPageChanged() {
    final page = (_controller.page ?? 0).round();
    widget.onFilterChanged(widget.filters[page]);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Widget _buildCarousel(double itemSize) {
    return Container(
      height: itemSize,
      margin: widget.padding,
      child: PageView.builder(
        controller: _controller,
        itemCount: widget.filters.length,
        itemBuilder: (context, index) {
          return Center(
            child: FilterItem(
              color: itemColor(index),
              onFilterSelected: () {},
            ),
          );
        },
      ),
    );
  }
}

とともにPageViewController追加、5FilterItemウィジェットは同時に画面上に表示されます。 スクロールすると写真のフィルターが変わりますが、 のFilterItemウィジェットのサイズは変わりません。

それぞれを包みますFilterItemウィジェット付きAnimatedBuilderそれぞれの視覚的なプロパティを変更するにはFilterItemウィジェットのスクロール位置が変化します。

Widget _buildCarousel(double itemSize) {
  return Container(
    height: itemSize,
    margin: widget.padding,
    child: PageView.builder(
      controller: _controller,
      itemCount: widget.filters.length,
      itemBuilder: (context, index) {
        return Center(
          child: AnimatedBuilder(
            animation: _controller,
            builder: (context, child) {
              return FilterItem(
                color: itemColor(index),
                onFilterSelected: () => {},
              );
            },
          ),
        );
      },
    ),
  );
}

AnimatedBuilderウィジェットは毎回再構築されます_controllerスクロール位置が変わります。 これらの再構築により、FilterItemユーザーがドラッグするとサイズと不透明度が変わります。

それぞれに適切なスケールと不透明度を計算します。FilterItem内のウィジェットAnimatedBuilderと それらの値を適用します。

Widget _buildCarousel(double itemSize) {
  return Container(
    height: itemSize,
    margin: widget.padding,
    child: PageView.builder(
      controller: _controller,
      itemCount: widget.filters.length,
      itemBuilder: (context, index) {
        return Center(
          child: AnimatedBuilder(
            animation: _controller,
            builder: (context, child) {
              if (!_controller.hasClients ||
                  !_controller.position.hasContentDimensions) {
                // The PageViewController isn’t connected to the
                // PageView widget yet. Return an empty box.
                return const SizedBox();
              }

              // The integer index of the current page,
              // 0, 1, 2, 3, and so on
              final selectedIndex = _controller.page!.roundToDouble();

              // The fractional amount that the current filter
              // is dragged to the left or right, for example, 0.25 when
              // the current filter is dragged 25% to the left.
              final pageScrollAmount = _controller.page! - selectedIndex;

              // The page-distance of a filter just before it
              // moves off-screen.
              const maxScrollDistance = _filtersPerScreen / 2;

              // The page-distance of this filter item from the
              // currently selected filter item.
              final pageDistanceFromSelected =
                  (selectedIndex - index + pageScrollAmount).abs();

              // The distance of this filter item from the
              // center of the carousel as a percentage, that is, where the selector
              // ring sits.
              final percentFromCenter =
                  1.0 - pageDistanceFromSelected / maxScrollDistance;

              final itemScale = 0.5 + (percentFromCenter * 0.5);
              final opacity = 0.25 + (percentFromCenter * 0.75);

              return Transform.scale(
                scale: itemScale,
                child: Opacity(
                  opacity: opacity,
                  child: FilterItem(
                    color: itemColor(index),
                    onFilterSelected: () => () {},
                  ),
                ),
              );
            },
          ),
        );
      },
    ),
  );
}

FilterItemウィジェットが縮小してフェードするようになりました 画面の中心から遠ざかるにつれて遠ざかります。

選択したフィルターを変更するメソッドを追加します。FilterItemウィジェットがタップされた状態です。

void _onFilterTapped(int index) {
  _controller.animateToPage(
    index,
    duration: const Duration(milliseconds: 450),
    curve: Curves.ease,
  );
}

それぞれを設定しますFilterItem呼び出すウィジェット_onFilterTappedタップすると。

FilterItem(
  color: itemColor(index),
  onFilterSelected: () => _onFilterTapped,
),

おめでとう! これで、ドラッグおよびタップ可能な写真フィルター カルーセルが完成しました。

インタラクティブな例

import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ViewportOffset;

void main() {
  runApp(
    const MaterialApp(
      home: ExampleInstagramFilterSelection(),
      debugShowCheckedModeBanner: false,
    ),
  );
}

@immutable
class ExampleInstagramFilterSelection extends StatefulWidget {
  const ExampleInstagramFilterSelection({super.key});

  @override
  State<ExampleInstagramFilterSelection> createState() =>
      _ExampleInstagramFilterSelectionState();
}

class _ExampleInstagramFilterSelectionState
    extends State<ExampleInstagramFilterSelection> {
  final _filters = [
    Colors.white,
    ...List.generate(
      Colors.primaries.length,
      (index) => Colors.primaries[(index * 4) % Colors.primaries.length],
    )
  ];

  final _filterColor = ValueNotifier<Color>(Colors.white);

  void _onFilterChanged(Color value) {
    _filterColor.value = value;
  }

  @override
  Widget build(BuildContext context) {
    return Material(
      color: Colors.black,
      child: Stack(
        children: [
          Positioned.fill(
            child: _buildPhotoWithFilter(),
          ),
          Positioned(
            left: 0.0,
            right: 0.0,
            bottom: 0.0,
            child: _buildFilterSelector(),
          ),
        ],
      ),
    );
  }

  Widget _buildPhotoWithFilter() {
    return ValueListenableBuilder(
      valueListenable: _filterColor,
      builder: (context, color, child) {
        return Image.network(
          'https://docs.flutter.dev/cookbook/img-files/effects/instagram-buttons/millenial-dude.jpg',
          color: color.withOpacity(0.5),
          colorBlendMode: BlendMode.color,
          fit: BoxFit.cover,
        );
      },
    );
  }

  Widget _buildFilterSelector() {
    return FilterSelector(
      onFilterChanged: _onFilterChanged,
      filters: _filters,
    );
  }
}

@immutable
class FilterSelector extends StatefulWidget {
  const FilterSelector({
    super.key,
    required this.filters,
    required this.onFilterChanged,
    this.padding = const EdgeInsets.symmetric(vertical: 24),
  });

  final List<Color> filters;
  final void Function(Color selectedColor) onFilterChanged;
  final EdgeInsets padding;

  @override
  State<FilterSelector> createState() => _FilterSelectorState();
}

class _FilterSelectorState extends State<FilterSelector> {
  static const _filtersPerScreen = 5;
  static const _viewportFractionPerItem = 1.0 / _filtersPerScreen;

  late final PageController _controller;
  late int _page;

  int get filterCount => widget.filters.length;

  Color itemColor(int index) => widget.filters[index % filterCount];

  @override
  void initState() {
    super.initState();
    _page = 0;
    _controller = PageController(
      initialPage: _page,
      viewportFraction: _viewportFractionPerItem,
    );
    _controller.addListener(_onPageChanged);
  }

  void _onPageChanged() {
    final page = (_controller.page ?? 0).round();
    if (page != _page) {
      _page = page;
      widget.onFilterChanged(widget.filters[page]);
    }
  }

  void _onFilterTapped(int index) {
    _controller.animateToPage(
      index,
      duration: const Duration(milliseconds: 450),
      curve: Curves.ease,
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scrollable(
      controller: _controller,
      axisDirection: AxisDirection.right,
      physics: const PageScrollPhysics(),
      viewportBuilder: (context, viewportOffset) {
        return LayoutBuilder(
          builder: (context, constraints) {
            final itemSize = constraints.maxWidth * _viewportFractionPerItem;
            viewportOffset
              ..applyViewportDimension(constraints.maxWidth)
              ..applyContentDimensions(0.0, itemSize * (filterCount - 1));

            return Stack(
              alignment: Alignment.bottomCenter,
              children: [
                _buildShadowGradient(itemSize),
                _buildCarousel(
                  viewportOffset: viewportOffset,
                  itemSize: itemSize,
                ),
                _buildSelectionRing(itemSize),
              ],
            );
          },
        );
      },
    );
  }

  Widget _buildShadowGradient(double itemSize) {
    return SizedBox(
      height: itemSize * 2 + widget.padding.vertical,
      child: const DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              Colors.transparent,
              Colors.black,
            ],
          ),
        ),
        child: SizedBox.expand(),
      ),
    );
  }

  Widget _buildCarousel({
    required ViewportOffset viewportOffset,
    required double itemSize,
  }) {
    return Container(
      height: itemSize,
      margin: widget.padding,
      child: Flow(
        delegate: CarouselFlowDelegate(
          viewportOffset: viewportOffset,
          filtersPerScreen: _filtersPerScreen,
        ),
        children: [
          for (int i = 0; i < filterCount; i++)
            FilterItem(
              onFilterSelected: () => _onFilterTapped(i),
              color: itemColor(i),
            ),
        ],
      ),
    );
  }

  Widget _buildSelectionRing(double itemSize) {
    return IgnorePointer(
      child: Padding(
        padding: widget.padding,
        child: SizedBox(
          width: itemSize,
          height: itemSize,
          child: const DecoratedBox(
            decoration: BoxDecoration(
              shape: BoxShape.circle,
              border: Border.fromBorderSide(
                BorderSide(width: 6, color: Colors.white),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class CarouselFlowDelegate extends FlowDelegate {
  CarouselFlowDelegate({
    required this.viewportOffset,
    required this.filtersPerScreen,
  }) : super(repaint: viewportOffset);

  final ViewportOffset viewportOffset;
  final int filtersPerScreen;

  @override
  void paintChildren(FlowPaintingContext context) {
    final count = context.childCount;

    // All available painting width
    final size = context.size.width;

    // The distance that a single item "page" takes up from the perspective
    // of the scroll paging system. We also use this size for the width and
    // height of a single item.
    final itemExtent = size / filtersPerScreen;

    // The current scroll position expressed as an item fraction, e.g., 0.0,
    // or 1.0, or 1.3, or 2.9, etc. A value of 1.3 indicates that item at
    // index 1 is active, and the user has scrolled 30% towards the item at
    // index 2.
    final active = viewportOffset.pixels / itemExtent;

    // Index of the first item we need to paint at this moment.
    // At most, we paint 3 items to the left of the active item.
    final min = math.max(0, active.floor() - 3).toInt();

    // Index of the last item we need to paint at this moment.
    // At most, we paint 3 items to the right of the active item.
    final max = math.min(count - 1, active.ceil() + 3).toInt();

    // Generate transforms for the visible items and sort by distance.
    for (var index = min; index <= max; index++) {
      final itemXFromCenter = itemExtent * index - viewportOffset.pixels;
      final percentFromCenter = 1.0 - (itemXFromCenter / (size / 2)).abs();
      final itemScale = 0.5 + (percentFromCenter * 0.5);
      final opacity = 0.25 + (percentFromCenter * 0.75);

      final itemTransform = Matrix4.identity()
        ..translate((size - itemExtent) / 2)
        ..translate(itemXFromCenter)
        ..translate(itemExtent / 2, itemExtent / 2)
        ..multiply(Matrix4.diagonal3Values(itemScale, itemScale, 1.0))
        ..translate(-itemExtent / 2, -itemExtent / 2);

      context.paintChild(
        index,
        transform: itemTransform,
        opacity: opacity,
      );
    }
  }

  @override
  bool shouldRepaint(covariant CarouselFlowDelegate oldDelegate) {
    return oldDelegate.viewportOffset != viewportOffset;
  }
}

@immutable
class FilterItem extends StatelessWidget {
  const FilterItem({
    super.key,
    required this.color,
    this.onFilterSelected,
  });

  final Color color;
  final VoidCallback? onFilterSelected;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onFilterSelected,
      child: AspectRatio(
        aspectRatio: 1.0,
        child: Padding(
          padding: const EdgeInsets.all(8),
          child: ClipOval(
            child: Image.network(
              'https://docs.flutter.dev/cookbook/img-files/effects/instagram-buttons/millenial-texture.jpg',
              color: color.withOpacity(0.5),
              colorBlendMode: BlendMode.hardLight,
            ),
          ),
        ),
      ),
    );
  }
}